#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json, pickle, os, textwrap, sys, re

WIDTH = 60
GAMENAME = ""

# ---------- 工具 ----------
def say(txt): print(textwrap.fill(txt, width=WIDTH, replace_whitespace=False))

def choose(opts):
    """支持玩家输入 back / skip / 数字"""
    while True:
        for i, o in enumerate(opts, 1):
            say(f"{i} {o['text']}")
        cmd = input("> ").strip().lower()
        if cmd == "back":
            return "back"
        if cmd == "skip":
            return "skip"
        try:
            idx = int(cmd) - 1
            if 0 <= idx < len(opts):
                return idx
            say(f"请输入 1-{len(opts)} 之间的数字！")
        except ValueError:
            say("请输入有效数字，或 back / skip！")

# ---------- 成就库 ----------
# 从 JSON 文件加载成就定义（去除硬编码）
ACHIEVES = None
try:
    ach_path = os.path.join('content', 'achievements.json')
    if not os.path.exists(ach_path):
        ach_path = 'achievements.json'
    with open(ach_path, encoding='utf-8') as _af:
        ACHIEVES = json.load(_af)
except Exception:
    ACHIEVES = {}

# ---------- 引擎中追加：生产力工具（冲突检测与剧情地图导出）
try:
    import networkx as nx
except Exception:
    nx = None
try:
    import matplotlib.pyplot as plt
except Exception:
    plt = None
try:
    import matplotlib.patches as mpatches
except Exception:
    mpatches = None

# ---------- 游戏主类 ----------
class Game:
    def __init__(self, story_file):
        with open(story_file, encoding='utf-8') as f:
            self.data = json.load(f)
        self.chap = self.data['init']['chapter']
        self.vars = self.data['init']['vars'].copy()
        self.save_file = "save.json"
        # ---- 新增 ----
        self.history = []          # 回滚栈
        self.achieve = set()       # 已解锁成就
        self.start_time = None     # 速用时
        self.quantum_flag = False  # 是否触发量子
        self.rollback_count = 0    # 累计回滚次数
        # 成就定义
        self.achieve_defs = ACHIEVES or {}
        # 合并扩展包章节
        self.merge_expansions()
        # 将扩展入口注入主线枢纽
        self.inject_expansion_portals()
        self.load()

    def save(self):
        data = {
            'chapter': self.chap,
            'vars': self.vars,
            'history': self.history,
            'achieve': list(self.achieve),
            'start_time': self.start_time,
            'quantum_flag': self.quantum_flag,
            'rollback_count': self.rollback_count,
        }
        with open(self.save_file, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False)

    def load(self):
        # 优先读取 JSON 存档；如有旧版 PKL 则自动迁移
        if os.path.exists(self.save_file):
            with open(self.save_file, 'r', encoding='utf-8') as f:
                d = json.load(f)
            self.chap = d.get('chapter', self.chap)
            self.vars = d.get('vars', self.vars)
            self.history = d.get('history', [])
            self.achieve = set(d.get('achieve', []))
            self.start_time = d.get('start_time', self.start_time)
            self.quantum_flag = d.get('quantum_flag', False)
            self.rollback_count = d.get('rollback_count', 0)
            say("(已自动读档)")
            return True
        else:
            legacy = "save.pkl"
            if os.path.exists(legacy):
                with open(legacy, 'rb') as f:
                    d = pickle.load(f)
                self.chap = d.get('chapter', self.chap)
                self.vars = d.get('vars', self.vars)
                self.history = d.get('history', [])
                ach = d.get('achieve', set())
                self.achieve = set(ach) if isinstance(ach, (set, list)) else set()
                self.start_time = d.get('start_time', os.times().user)
                self.quantum_flag = d.get('quantum_flag', False)
                self.rollback_count = d.get('rollback_count', 0)
                # 写入为 JSON 并尝试移除旧存档
                self.save()
                say("(已从旧版 PKL 存档迁移为 JSON)")
                try:
                    os.remove(legacy)
                except OSError:
                    pass
                return True
            self.start_time = os.times().user
            return False

    def merge_expansions(self, base_dir=None):
        """从 content/expansion 目录合并扩展 JSON 的章节。
        只合并 chapters 字段，忽略 init，保障基础结构稳定。
        """
        try:
            if base_dir is None:
                base_dir = os.path.join(os.path.dirname(__file__), 'content', 'expansion')
            if not os.path.isdir(base_dir):
                return
            for name in os.listdir(base_dir):
                if not name.endswith('.json'):
                    continue
                p = os.path.join(base_dir, name)
                with open(p, encoding='utf-8') as ef:
                    extra = json.load(ef)
                chapters = extra.get('chapters') or extra
                if isinstance(chapters, dict):
                    self.data['chapters'].update(chapters)
        except Exception:
            # 忽略扩展解析错误，避免影响运行
            pass

    def inject_expansion_portals(self):
        chapters = self.data.get('chapters', {})
        def ensure_choice(chap_key, label, next_key, require=None, setvals=None):
            chap = chapters.get(chap_key)
            if not chap:
                return
            choices = chap.setdefault('choices', [])
            if any(c.get('next') == next_key for c in choices):
                return
            choice = {"text": label, "next": next_key}
            if require:
                choice["require"] = require
            if setvals:
                choice["set"] = setvals
            choices.append(choice)
        # 发现扩展入口章节
        # 固定入口键（根据现有扩展约定）：
        # foundry_entry、fables_entry、season_prelude、tiers_entry
        if chapters.get('foundry_entry'):
            hub = 'city_entry' if chapters.get('city_entry') else None
            if hub:
                ensure_choice(hub, '前往铸造区（扩展）', 'foundry_entry')
        if chapters.get('fables_entry'):
            hub = 'bazaar_inner' if chapters.get('bazaar_inner') else ('bazaar_outer' if chapters.get('bazaar_outer') else ('city_entry' if chapters.get('city_entry') else None))
            if hub:
                ensure_choice(hub, '进入寓言集市（扩展）', 'fables_entry')
        if chapters.get('season_prelude'):
            hub = 'theatre_entry' if chapters.get('theatre_entry') else None
            if hub:
                ensure_choice(hub, '剧院新季（扩展）', 'season_prelude')
        if chapters.get('tiers_entry'):
            hub = 'tower_lobby' if chapters.get('tower_lobby') else None
            if hub:
                ensure_choice(hub, '塔分层探索（扩展）', 'tiers_entry')

    def eval_cond(self, cond):
        if not cond: return True
        if ">=" in cond:
            k, v = cond.split(">="); return self.vars[k.strip()] >= int(v)
        if "<=" in cond:
            k, v = cond.split("<="); return self.vars[k.strip()] <= int(v)
        if "==" in cond:
            k, v = cond.split("=="); return self.vars[k.strip()] == int(v)
        if cond.startswith("!"):
            return not self.vars.get(cond[1:].strip(), False)
        return True

    def apply_set(self, sett):
        for k, v in sett.items():
            if isinstance(v, bool):
                self.vars[k] = v
            else:
                self.vars[k] = self.vars.get(k, 0) + v

    def unlock(self, key):
        if key not in self.achieve:
            self.achieve.add(key)
            ach = self.achieve_defs.get(key, {'name': key, 'desc': ''})
            say(f"\n🏆 成就解锁：{ach.get('name', key)}\n   {ach.get('desc', '')}")
            self.save()

    def evaluate_achievements(self, event, ctx=None):
        ctx = ctx or {}
        for key, ach in self.achieve_defs.items():
            if key in self.achieve:
                continue
            if ach.get('event') != event:
                continue
            cond = ach.get('condition') or {}
            ok = True
            if 'threshold' in cond:
                ok = ok and (self.rollback_count >= cond['threshold'])
            if 'elapsed_lt' in cond:
                ok = ok and ((os.times().user - self.start_time) < cond['elapsed_lt'])
            if 'ending_contains' in cond:
                end_chap = ctx.get('end_chap', '')
                ok = ok and (cond['ending_contains'] in end_chap)
            if 'any_of' in cond and 'count_gte' in cond:
                endings = [a for a in self.achieve if a in cond['any_of']]
                ok = ok and (len(endings) >= cond['count_gte'])
            if ok:
                self.unlock(key)

    def quantum_choice(self, choices):
        """检测到 quantum 字段时自动概率坍缩"""
        import random
        self.quantum_flag = True
        self.evaluate_achievements('quantum_triggered')
        say("\n⚛️  量子分支已坍缩...")
        weights = [max(0.0, float(c.get('prob', 1))) for c in choices]
        total = sum(weights)
        if total <= 0:
            sel = choices[0]
            self.evaluate_achievements('quantum_choice_selected', {'choice_text': sel.get('text',''), 'next': sel.get('next','')})
            return sel
        r = random.random() * total
        cum = 0.0
        for c, w in zip(choices, weights):
            cum += w
            if r <= cum:
                self.evaluate_achievements('quantum_choice_selected', {'choice_text': c.get('text',''), 'next': c.get('next','')})
                return c
        self.evaluate_achievements('quantum_choice_selected', {'choice_text': choices[-1].get('text',''), 'next': choices[-1].get('next','')})
        return choices[-1]

    def roll_prob_event(self, chap):
        """在章节进入时，根据 events 列表进行一次概率事件判定。
        事件结构示例：
        {
          'name': '电梯失速', 'prob': 0.15, 'text': '...',
          'set': {...}, 'next': 'shaft_reset', 'achieve': 'event_x', 'require': 'awake>=1'
        }
        - prob: 概率权重（>=0），自动归一化抽选
        - require: 采用与 choices 相同的条件表达式
        - next 可选：存在则直接跳转并跳过本章选择
        - set 可选：应用变量修改
        - achieve 可选：立即解锁指定成就键
        返回 True 表示发生跳转（需要重新渲染新章节），False 表示不中断继续本章流程。
        """
        events = chap.get('events') or []
        events = [e for e in events if self.eval_cond(e.get('require'))]
        if not events:
            return False
        import random
        weights = [max(0.0, float(e.get('prob', 0))) for e in events]
        total = sum(weights)
        if total <= 0:
            return False
        r = random.random() * total
        cum = 0.0
        for e, w in zip(events, weights):
            cum += w
            if r <= cum:
                title = e.get('title') or e.get('name') or ''
                if title:
                    say(f"\n☄️ 概率事件触发：{title}")
                else:
                    say("\n☄️ 概率事件触发")
                if e.get('text'):
                    say(e['text'])
                self.apply_set(e.get('set', {}))
                if e.get('achieve'):
                    self.unlock(e['achieve'])
                self.evaluate_achievements('prob_event_triggered', {'event_name': title})
                nxt = e.get('next')
                if nxt:
                    self.history.append({
                        'chapter': self.chap,
                        'vars': self.vars.copy(),
                        'sel_text': f"[事件]{title or '随机事件'}"
                    })
                    if not self.ensure_chapter_exists(nxt, {'from': self.chap, 'event': title}):
                        say("⚠️ 事件指向的章节尚未编写，返回城市入口。")
                        nxt = 'city_entry' if self.get_chapter('city_entry') else self.chap
                    self.chap = nxt
                    self.save()
                    return True
                self.save()
                break
        return False


    def get_chapter(self, name):
        if not name:
            return None
        # 仅使用主剧本章节，忽略 AI 覆盖层
        return self.data['chapters'].get(name)


    def ensure_chapter_exists(self, name, ctx=None):
        return self.get_chapter(name) is not None




    def run(self):
        if not self.load():
            say(f"=== {GAMENAME} ===")
        while True:
            chap = self.get_chapter(self.chap)
            say("\n" + chap['text'])

            # 概率事件判定（优先于结局与选择）
            if self.roll_prob_event(chap):
                continue

            # 结局处理
            if chap.get('is_end'):
                self.evaluate_achievements('game_end', {'end_chap': self.chap})
                self.evaluate_achievements('achievements_unlocked')
                say("\n输入 r 重玩，其他退出。")
                if input("> ").lower() == 'r':
                    os.remove(self.save_file) if os.path.exists(self.save_file) else None
                    default_story = os.path.join('content', 'story.json')
                    self.__init__(sys.argv[1] if len(sys.argv) > 1 else default_story)
                    continue
                break

            choices = [c for c in chap['choices'] if self.eval_cond(c.get('require'))]

            # 移除：不再为非结局章节追加“继续由AI续写”，仅在结局通过番外事件触发AI续写
            # （保留原有 choices，不做运行期注入）

            # 量子分支检测
            if any('prob' in c for c in choices):
                sel = self.quantum_choice(choices)
                idx = choices.index(sel)
            else:
                idx = choose(choices)
                if idx == "back":
                    self.rollback()
                    continue
                if idx == "skip":
                    self.evaluate_achievements('skip_used')
                    idx = 0

            # 正常选择
            sel = choices[idx]
            self.history.append({
                'chapter': self.chap,
                'vars': self.vars.copy(),
                'sel_text': sel['text']
            })
            self.apply_set(sel.get('set', {}))
            nxt = sel['next']
            if not self.ensure_chapter_exists(nxt, {'from': self.chap, 'sel_text': sel.get('text','')}):
                say("⚠️ 该分支尚未编写，返回城市入口。")
                nxt = 'city_entry' if self.get_chapter('city_entry') else self.chap
            self.chap = nxt
            self.save()

    # ---------------- 回滚 ----------------
    def rollback(self):
        if not self.history:
            say("❌ 没有可回滚的记录！")
            return
        prev = self.history.pop()
        self.chap = prev['chapter']
        self.vars = prev['vars']
        say(f"↩️  已回滚：{prev['sel_text']}")
        self.rollback_count += 1
        self.evaluate_achievements('rollback_count')


    # ---------- 剧情生产力工具 ----------
    def collect_var_effects(self):
        effects = {}
        for k, v in (self.data.get('init', {}).get('vars', {}) or {}).items():
            effects[k] = {
                'init': v, 'pos': 0, 'neg': 0,
                'write_true': False, 'write_false': False,
                'reads': 0, 'writes': 0,
            }
        def ensure(k):
            if k not in effects:
                effects[k] = {
                    'init': 0, 'pos': 0, 'neg': 0,
                    'write_true': False, 'write_false': False,
                    'reads': 0, 'writes': 0,
                }
        for name, chap in (self.data.get('chapters') or {}).items():
            for c in chap.get('choices', []) or []:
                for k, v in (c.get('set') or {}).items():
                    ensure(k)
                    effects[k]['writes'] += 1
                    if isinstance(v, bool):
                        if v:
                            effects[k]['write_true'] = True
                        else:
                            effects[k]['write_false'] = True
                    else:
                        if v >= 0:
                            effects[k]['pos'] += v
                        else:
                            effects[k]['neg'] += v
            for e in chap.get('events', []) or []:
                for k, v in (e.get('set') or {}).items():
                    ensure(k)
                    effects[k]['writes'] += 1
                    if isinstance(v, bool):
                        if v:
                            effects[k]['write_true'] = True
                        else:
                            effects[k]['write_false'] = True
                    else:
                        if v >= 0:
                            effects[k]['pos'] += v
                        else:
                            effects[k]['neg'] += v
        def scan_require(cond):
            if not cond:
                return []
            cond = cond.strip()
            if cond.startswith('!'):
                return [cond[1:].strip()]
            for op in ['>=', '<=', '==']:
                if op in cond:
                    k, _v = cond.split(op, 1)
                    return [k.strip()]
            return []
        for name, chap in (self.data.get('chapters') or {}).items():
            for c in chap.get('choices', []) or []:
                vname = scan_require(c.get('require'))
                for vn in vname:
                    ensure(vn)
                    effects[vn]['reads'] += 1
            for e in chap.get('events', []) or []:
                vname = scan_require(e.get('require'))
                for vn in vname:
                    ensure(vn)
                    effects[vn]['reads'] += 1
        return effects

    def detect_conflicts(self):
        effects = self.collect_var_effects()
        problems = {
            'unsatisfiable': [],
            'unwritten_but_read': [],
            'written_but_unread': [],
            'missing_targets': [],
        }
        def can_satisfy(var, op, val):
            eff = effects.get(var, {'init': 0, 'pos': 0, 'neg': 0, 'write_true': False})
            if op == '!':
                return True
            if isinstance(val, bool):
                if val:
                    return bool(eff.get('init')) or eff.get('write_true')
                else:
                    return (not bool(eff.get('init'))) or eff.get('write_false')
            init = eff.get('init') or 0
            min_possible = init + eff.get('neg')
            max_possible = init + eff.get('pos')
            if op == '==':
                return (min_possible <= val <= max_possible)
            if op == '>=':
                return max_possible >= val
            if op == '<=':
                return min_possible <= val
            return True
        def parse_req(cond):
            if not cond:
                return None
            cond = cond.strip()
            if cond.startswith('!'):
                return (cond[1:].strip(), '!', None)
            for op in ['>=', '<=', '==']:
                if op in cond:
                    k, v = cond.split(op, 1)
                    v = v.strip()
                    try:
                        vv = int(v)
                    except Exception:
                        vv = 1 if v in ['true', 'True'] else 0 if v in ['false', 'False'] else v
                    return (k.strip(), op, vv)
            return None
        for name, chap in (self.data.get('chapters') or {}).items():
            for c in chap.get('choices', []) or []:
                nxt = c.get('next')
                if nxt and self.data['chapters'].get(nxt) is None:
                    problems['missing_targets'].append({'from': name, 'to': nxt, 'where': 'choice'})
                req = parse_req(c.get('require'))
                if req and not can_satisfy(*req):
                    problems['unsatisfiable'].append({'chapter': name, 'require': c.get('require'), 'where': 'choice'})
            for e in chap.get('events', []) or []:
                nxt = e.get('next')
                if nxt and self.data['chapters'].get(nxt) is None:
                    problems['missing_targets'].append({'from': name, 'to': nxt, 'where': 'event'})
                req = parse_req(e.get('require'))
                if req and not can_satisfy(*req):
                    problems['unsatisfiable'].append({'chapter': name, 'require': e.get('require'), 'where': 'event'})
        for k, eff in effects.items():
            if eff['reads'] > 0 and eff['writes'] == 0:
                problems['unwritten_but_read'].append(k)
            if eff['writes'] > 0 and eff['reads'] == 0:
                problems['written_but_unread'].append(k)
        return problems

    def export_png(self, out_path='story.png'):
        if nx is None or plt is None or mpatches is None:
            say('请先安装 networkx 与 matplotlib：pip install networkx matplotlib')
            return False
        G = nx.DiGraph()
        chapters = self.data.get('chapters') or {}
        exp_set = set(getattr(self, 'expansion_chapters', []))
        for name, chap in chapters.items():
            node_type = 'end' if chap.get('is_end') else ('exp' if name in exp_set else 'main')
            G.add_node(name, node_type=node_type)
            for c in chap.get('choices', []) or []:
                nxt = c.get('next')
                if nxt:
                    G.add_edge(name, nxt)
            for e in chap.get('events', []) or []:
                nxt = e.get('next')
                if nxt:
                    G.add_edge(name, nxt)
        # 为边带来的未显式节点补齐类型
        for n in list(G.nodes()):
            if 'node_type' not in G.nodes[n]:
                G.nodes[n]['node_type'] = 'main'
        pos = nx.spring_layout(G, k=0.8, seed=47)
        main_nodes = [n for n, d in G.nodes(data=True) if d['node_type'] == 'main']
        exp_nodes = [n for n, d in G.nodes(data=True) if d['node_type'] == 'exp']
        end_nodes = [n for n, d in G.nodes(data=True) if d['node_type'] == 'end']
        plt.figure(figsize=(24, 16), dpi=300)
        nx.draw_networkx_edges(G, pos, alpha=0.25, arrows=True, arrowstyle='-|>', arrowsize=8)
        nx.draw_networkx_nodes(G, pos, nodelist=main_nodes, node_color='#4c78a8', node_size=600)
        nx.draw_networkx_nodes(G, pos, nodelist=exp_nodes, node_color='#9c6ade', node_size=600)
        nx.draw_networkx_nodes(G, pos, nodelist=end_nodes, node_color='#e45756', node_shape='s', node_size=700)
        nx.draw_networkx_labels(G, pos, labels={n: n for n in G.nodes()}, font_size=7)
        patches = [
            mpatches.Patch(color='#4c78a8', label='主线（base）'),
            mpatches.Patch(color='#9c6ade', label='暗线（expansion）'),
            mpatches.Patch(color='#e45756', label='结局（ending）'),
        ]
        plt.legend(handles=patches, loc='lower left')
        plt.axis('off')
        plt.tight_layout()
        plt.savefig(out_path)
        say(f'已导出剧情地图：{out_path}')
        return True


# 在引擎中追加：生产力工具（冲突检测与剧情地图导出）
try:
    import networkx as nx
except Exception:
    nx = None
try:
    import matplotlib.pyplot as plt
except Exception:
    plt = None
try:
    import matplotlib.patches as mpatches
except Exception:
    mpatches = None


# ---------- 入口 ----------
if __name__ == '__main__':
    default_story = os.path.join('content', 'story.json')
    # 支持生产力工具命令
    if len(sys.argv) > 1 and sys.argv[1] in ['--check', '--export-png']:
        g = Game(sys.argv[2] if len(sys.argv) > 2 else default_story)
        # 记录扩展章节名集合
        try:
            base_dir = os.path.join(os.path.dirname(__file__), 'content', 'expansion')
            g.expansion_chapters = set()
            if os.path.isdir(base_dir):
                for name in os.listdir(base_dir):
                    if name.endswith('.json'):
                        with open(os.path.join(base_dir, name), encoding='utf-8') as ef:
                            extra = json.load(ef)
                        chapters = extra.get('chapters') or extra
                        if isinstance(chapters, dict):
                            g.expansion_chapters.update(chapters.keys())
        except Exception:
            g.expansion_chapters = set()
        if sys.argv[1] == '--check':
            probs = g.detect_conflicts()
            say('=== 冲突检测报告 ===')
            say(f"不可满足的 require：{len(probs['unsatisfiable'])} 条")
            for p in probs['unsatisfiable'][:50]:
                say(f"[UNSAT] {p['where']}@{p['chapter']} require='{p['require']}'")
            say(f"读取但未写入的变量：{', '.join(probs['unwritten_but_read']) or '无'}")
            say(f"写入但未读取的变量：{', '.join(probs['written_but_unread']) or '无'}")
            say(f"指向不存在章节的跳转：{len(probs['missing_targets'])} 条")
            for m in probs['missing_targets'][:50]:
                say(f"[MISSING] {m['where']} {m['from']} -> {m['to']}")
            sys.exit(0)
        elif sys.argv[1] == '--export-png':
            ok = g.export_png('story.png')
            sys.exit(0 if ok else 1)
    else:
        g = Game(sys.argv[1] if len(sys.argv) > 1 else default_story)
        try:
            g.run()
        except KeyboardInterrupt:
            say("\n已手动退出，进度已自动保存。")